ci(test-tiers): per-PR gate for a package adopting a tier file without reading OS_TEST_TIERS - #17275
Merged
Merged
Conversation
…t reading the switch scripts/nightly-tiers.mjs moved the e2e and live tiers off the per-PR and merge-queue runs onto a nightly, gated by OS_TEST_TIERS. A package that adopts its first *.e2e.test.* or *.live.test.* file without reading that switch runs the file in the merge queue and its whole suite under the nightly, both silently; --check makes that loud only on the nightly, one night late, and its refusal then blocks the whole tier run for one package's omission. check-tier-file-adoption.mjs is the per-PR half. It is the STATIC route: --check boots vitest twice per tier-owning package to measure what it collects, which is the right instrument for a nightly and the wrong one for every PR. This shares --check's own tier-file predicate and package walk (tierPackages) and spawns nothing. The detector walks the vitest config's local import closure rather than grepping for the variable: on the one package that owns tier files today, the name appears 9 times raw and 0 times with comments masked -- every occurrence is prose, and the real read is an import edge. Both wrong detectors are pinned as controls. Zero tier files is a green that PRINTS THE ZERO: a gate reporting clean by never looking is indistinguishable from one that looked, unless the count is on the screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
baozhoutao
marked this pull request as ready for review
September 10, 2026 02:47
baozhoutao
enabled auto-merge
September 10, 2026 02:48
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #16494
scripts/nightly-tiers.mjs(#16455, PR #16481) moved thee2eandlivetest tiers off the per-PR and merge-queue runs onto a nightly onmain, gated byOS_TEST_TIERS. Its own header states what that does not buy: a package that adopts its first*.e2e.test.*or*.live.test.*file without reading the switch runs that file in the merge queue (nothing excludes it) and its whole suite under the nightly (nothing narrows it) — both silent.nightly-tiers.mjs --checkmakes that loud, but only on the nightly, one night after the PR landed, and its refusal then blocks the whole tier run for one package's omission.This adds the per-PR half:
scripts/check-tier-file-adoption.mjs, wired intolint.yml'slintjob (the requiredLint & Repo Gatescontext) aspnpm check:tier-file-adoption.Which route, and why
The card left the choice open and called it a cost question. Static walk, not
--check's vitest probe.--checkmeasures the truth — twovitest list --filesOnlyruns per tier-owning package, judged byjudgeCollection— and that is the right instrument for a nightly and the wrong one for every pull request: it boots vitest, twice per package, to answer a question about adoption. This gate spawns nothing. It answers the cheaper question statically: does the package's vitest configuration reach the switch at all? It imports--check's owntierPackagesandNIGHTLY_TIERSfromnightly-tiers.mjs, so the two can never disagree about which files are tier files or which packages own them, and it says so in its header that green here means "the switch is wired in", never "the partition is right" — the semantic verdict stays--check's, on the nightly.Measured on this branch: the gate's full run (self-test + real-tree sweep) is sub-second.
Why the detector cannot be a grep for the variable name
This is the load-bearing measurement, taken on
d03c3c96d6against the entire population of tier-owning packages, which is one:OS_TEST_TIERSrawpackages/cli/vitest.config.tspackages/cli/vitest-tiers.tsscripts/nightly-tiers.mjsAll nine occurrences in the two config files are prose. The real read is an import edge: the config imports
./vitest-tiers.js, which importsreadTierModeandselectTierFilesfrom../../scripts/nightly-tiers.mjs.So both obvious detectors are wrong, in opposite directions:
packages/cli, the one package that does this correctly. A 100% fabrication rate over today's whole population.The detector therefore walks the config's local relative-import closure and accepts either signal in a code position: a value import of the switch-reading surface (
readTierMode/selectTierFiles/OS_TEST_TIERS) from the reader module, or the variable named directly. Comments are masked with the sharedscripts/js-comment-mask.mjs(the modulecheck:comment-mask-adoptionexists to keep everyone on). Both wrong detectors are pinned as controls in the self-test, plus a type-only import (reads nothing at runtime → red) and an import ofisNightlyTierFilealone (a filename predicate, not a switch read → red).The three acceptance legs, measured
packages/clishape through a local moduletierFileCount === 0,owners === 0,packagesWalked > 0, and that the printed line carries a0for each tier by name⭐ Leg 3 is a reverse control, and a green that never looked would satisfy a weaker version of it. Two things make the zero real:
N nightly-tier test file(s) on disk (X e2e, Y live). "Clean" is not a sentence this gate can print without a count behind it.MEASUREMENT FAILED, distinct from a finding's 1. That is the one anti-vacuity floor kept; the selection floor its siblingcheck-registry-log-declaredcarries is deliberately inverted here and the header says why: zero engine-booting packages means that gate went blind, while zero tier files is a legitimate state of this tree — it is what the tree looked like beforepackages/cliadopted the tier.live-dialect-matrix.test.tsis not a tier file (liveis a tier a package adopts by naming a file into it, not a substring this gate hunts), so the zero in leg 3 is a judged zero, not an empty directory.The one-time red proof
Run against the real tree with one scratch tier file added to a non-reading package, then removed. Trap-guarded; the mutation was proven on disk before the run (
grep -con the injected marker), and the tree was proven clean afterwards (git status --porcelainempty). No permanent fixture.The count moved 65 → 66 → 65 and the package name and file path both appear in the finding.
Census re-derived on this branch — it has drifted from the card
The card's numbers were taken at PR #16481 /
770dd18205. Re-measured atd03c3c96d6:770dd18205)d03c3c96d6)vitest.config.tsfilesvitest.workspace*, no rootvitest.config.*)packages/cli)packages/cli)*.e2e.test.ts*.e2e.test.**.live.test.*filescheck:*tier*script inpackage.jsonControls taken in the same pass, on the same tree: the
*.live.test.*zero sits beside 3541*.test.*files matched by the same enumeration, so the zero is a reading and not a broken glob;OS_TEST_TIERSoccurs in 8 tracked files, and a nonsense control term returns 0.*.live.test.*files anywhere in the tree, the gate'slivearm — and leg 3's "no tier files" case for that tier — are validated against an empty real corpus. Their only coverage is the self-test's temporary fixture workspaces, which exercise the judging path and cannot tell you the arm has ever met a real file. The gate prints that fact itself on every self-test run (the OS_TEST_TIERS live-tier corpus is 0 file(s) — that arm is covered by FIXTURES ONLY) rather than leaving it to a PR body nobody re-reads. Thee2earm is not in that position: it is measured against 65 real files and one real reading package on every run.Scope held
⛔ No test file changed. ⛔ No package made to read the switch. The Ruling is explicit that the gate reports and the adopting PR fixes, and the diff is three files: the new script, its
package.jsonentry, and itslint.ymlstep.Changeset — graded, not invented
No
.changeset/*.md;skip-changesetinstead, on a measurement rather than a guess. The diff touches repo-rootscripts/,.github/workflows/lint.yml, and the rootpackage.json, which isprivate: true(@objectstack/spec-monorepo) and ships nothing. Across all 70 published (non-private) workspace packages there are exactly 10 distinctfiles[]entries —CHANGELOG.md,README.md,api-surface,dist,json-schema,liveness,llms.txt,prompts,spec-changes.json,src/**/*.zod.ts— every one package-local, and none reaches repo-rootscripts/or.github/. Positive control on the same instrument: that enumeration does returndistfor@objectstack/spec, so it is capable of reporting a genuinely published path. Nothing publishable moves, so an empty-frontmatter changeset is refused on principle (check:empty-changeset's ledger: it buys nothing the label does not and uniquely risks #4898) and no bump is invented.Clause-② — the card's
nostill holdsSurface is
scripts/**+lint.yml+ a rootpackage.jsonscript entry. Nopackages/**/src/**path, no published surface. The implementation needed no change underpackages/**, so the card's declaration is not contradicted.维护者速读(草稿)
改了什么 — 新增一个 per-PR 门禁
pnpm check:tier-file-adoption,挂进lint.yml。它静态判断:凡是拥有*.e2e.test.*/*.live.test.*文件的包,其 vitest 配置有没有真的读到OS_TEST_TIERS这个开关。没读到就红,点名包和文件。为什么改 — #16455 把 e2e/live 两层测试从 PR 与合并队列挪到了 nightly。但一个新采用该层的包如果不读开关,那个文件照样在合并队列里跑,而 nightly 又会把它整包跑一遍——两边都静默。原有的
nightly-tiers.mjs --check能发现,但只在 nightly 上、晚一夜,而且它一拒就把整轮 tier 运行为了一个包的疏漏全部挡住。这条门禁把发现点提前到引入它的那个 PR。风险与代价(含回滚) — 代价接近零:不启动 vitest,只读约 80 个 manifest 和几个配置文件,亚秒级。风险面是误报:检测器如果写错方向,会把今天唯一做对的⚠️ 已知局限:全树
packages/cli判红。这一点已被实测钉死(见上表:该包里OS_TEST_TIERS共 9 次全在注释里,真正的读取是 import 边),两个错误方向都做成了 self-test 的控制用例。回滚 = 删掉这三处改动,没有任何包的源码或测试被触碰。*.live.test.*文件为 0,该臂只有 fixture 覆盖,门禁自己每次都会把这句话打印出来。席位意见 — (待填)
你要做的 — 看一眼「Which route, and why」那节的取舍是否符合预期(静态拼写检查 vs. 启动 vitest 的语义检查),以及
skip-changeset的定级是否认可。其余无需操作。Verification
node scripts/check-tier-file-adoption.mjs --self-test— exit 0; 12 fixture-workspace cases + 8 pure-judge cases + population declaration + real-tree walk floor.node scripts/check-tier-file-adoption.mjs— exit 0 on this branch.node scripts/pm/dispatch-gates.mjs --commands(from the merge-base changeset, not a hand-written list) and run; results in the report.🤖 Generated with Claude Code
https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Generated by Claude Code